返回 金丹・瑞吉厨域试炼

我的第一个上线项目:从"本地王者"到"服务器乞丐"

博主
大约 20 分钟

我的第一个上线项目:从"本地王者"到"服务器乞丐"

"为什么生产环境显示不出来?我本地明明好好的!"凌晨两点,我在空荡荡的办公室里对着屏幕咆哮。距离产品上线还有6小时,前端页面一片空白,而我连Nginx是什么都不知道。从开发到上线,原来隔着十万八千个坑。

image-20260202164033526

一、本地环境搭建:我以为的"简单"其实不简单

1.1 那个让我怀疑人生的跨域问题

我按照教程写完了所有代码,前端访问后端接口时:

image-20260202164112934

text

Access to XMLHttpRequest at 'http://localhost:8080/employee/login' 
from origin 'http://localhost:5500' has been blocked by CORS policy

我的"解决方案":在Controller上疯狂加注解:

java

// 版本1:乱加注解
@CrossOrigin(origins = "*", allowedHeaders = "*", methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.OPTIONS, RequestMethod.PATCH, RequestMethod.HEAD, RequestMethod.TRACE})
@RestController
@RequestMapping("/employee")
public class EmployeeController {
    // ...
}

还是不行!为什么?

真正的问题:我的过滤器拦截了所有请求,包括OPTIONS预检请求,但过滤器里没有放行OPTIONS方法。

正确的解决方案

java

@WebFilter(filterName = "loginCheckFilter", urlPatterns = "/*")
@Slf4j
public class LoginCheckFilter implements Filter {
    
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        
        // 1. 放行OPTIONS预检请求
        if ("OPTIONS".equals(request.getMethod())) {
            chain.doFilter(request, response);
            return;
        }
        
        // 2. 白名单
        String[] freeUrls = {
            "/employee/login", "/employee/logout",
            "/backend/**", "/front/**"
        };
        
        String requestURI = request.getRequestURI();
        boolean isFree = check(freeUrls, requestURI);
        if (isFree) {
            chain.doFilter(request, response);
            return;
        }
        
        // 3. 检查登录状态
        if (request.getSession().getAttribute("employee") != null) {
            chain.doFilter(request, response);
            return;
        }
        
        // 4. 未登录
        response.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN")));
    }
    
    private boolean check(String[] urls, String requestURI) {
        AntPathMatcher pathMatcher = new AntPathMatcher();
        for (String url : urls) {
            if (pathMatcher.match(url, requestURI)) {
                return true;
            }
        }
        return false;
    }
}

1.2 那个让我熬夜的静态资源路径问题

我的前端页面打开是一片空白,控制台报错:

text

GET http://localhost:8080/backend/css/style.css 404

我检查了代码,静态资源明明在resources/backend/css/style.css啊!

问题:Spring Boot默认的静态资源路径是/static/public/resources/META-INF/resources,而我把资源放在了resources/backend,这不在默认扫描路径内。

解决方案:配置静态资源映射

java

@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
    
    @Override
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
        log.info("开始静态资源映射...");
        
        // 映射backend路径
        registry.addResourceHandler("/backend/**")
                .addResourceLocations("classpath:/backend/");
        
        // 映射front路径(用户端)
        registry.addResourceHandler("/front/**")
                .addResourceLocations("classpath:/front/");
    }
}

二、数据库设计:从"能用就行"到"优化性能"

image-20260202164140823

2.1 我的第一个员工表设计

sql

-- 我的"初版"员工表
CREATE TABLE employee (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(20),
    username VARCHAR(20),
    password VARCHAR(20),
    phone VARCHAR(11),
    sex VARCHAR(2),
    id_number VARCHAR(18),
    status INT,
    create_time DATETIME,
    update_time DATETIME,
    create_user BIGINT,
    update_user BIGINT
);

看起来没问题?导师指出了几个问题:

  1. 主键问题INT最大21亿,对于大型系统可能不够
  2. 密码明文存储VARCHAR(20)存明文密码,安全隐患
  3. 索引缺失:没有为usernamephone等常用查询字段加索引
  4. 性别存储:用VARCHAR(2)存储"男"/"女",浪费空间且不标准
  5. 时间字段:没有默认值,需要手动设置

2.2 优化后的数据库设计

sql

-- 优化后的员工表
CREATE TABLE `employee` (
  `id` bigint NOT NULL COMMENT '主键',
  `name` varchar(32) COLLATE utf8mb4_bin NOT NULL COMMENT '姓名',
  `username` varchar(32) COLLATE utf8mb4_bin NOT NULL COMMENT '用户名',
  `password` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '密码',
  `phone` varchar(11) COLLATE utf8mb4_bin NOT NULL COMMENT '手机号',
  `sex` varchar(2) COLLATE utf8mb4_bin NOT NULL COMMENT '性别',
  `id_number` varchar(18) COLLATE utf8mb4_bin NOT NULL COMMENT '身份证号',
  `status` int NOT NULL DEFAULT '1' COMMENT '状态 0:禁用,1:正常',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `create_user` bigint NOT NULL COMMENT '创建人',
  `update_user` bigint NOT NULL COMMENT '修改人',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `idx_username` (`username`) COMMENT '用户名唯一索引',
  KEY `idx_phone` (`phone`) COMMENT '手机号索引',
  KEY `idx_status` (`status`) COMMENT '状态索引',
  KEY `idx_create_time` (`create_time`) COMMENT '创建时间索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='员工信息';

改进点

  1. 主键bigint支持最多922亿亿条记录
  2. 密码varchar(64),可以存储MD5加密后的32位hex字符串
  3. 索引:为查询频繁的字段添加索引
  4. 默认值status默认1(正常),时间字段自动设置
  5. 字符集utf8mb4支持表情符号

2.3 初始化数据脚本

sql

-- 初始化管理员账户
INSERT INTO `employee` 
(`id`, `name`, `username`, `password`, `phone`, `sex`, `id_number`, `status`, `create_user`, `update_user`) 
VALUES 
(1, '管理员', 'admin', 'e10adc3949ba59abbe56e057f20f883e', '13812345678', '1', '110101199001011234', 1, 1, 1);
-- 密码:123456的MD5加密

三、登录功能实现:从"简单验证"到"安全防护"

image-20260202164219752

3.1 我的第一版登录代码

java

@PostMapping("/login")
public R<Employee> login(HttpServletRequest request, @RequestBody Employee employee) {
    // 1. 查询用户
    Employee emp = employeeMapper.selectByUsername(employee.getUsername());
    
    // 2. 验证密码
    if (emp != null && emp.getPassword().equals(employee.getPassword())) {
        // 3. 存入Session
        request.getSession().setAttribute("employee", emp.getId());
        return R.success(emp);
    }
    
    return R.error("登录失败");
}

问题一堆

  1. SQL注入风险:直接拼接SQL查询
  2. 密码明文传输:前端传明文,后端比较明文
  3. 没有异常处理:查询失败会直接抛异常
  4. 没有账户状态检查:禁用的账户也能登录
  5. 没有登录日志:无法追溯登录行为

3.2 生产级的登录实现

java

@Service
@Slf4j
public class EmployeeServiceImpl extends ServiceImpl<EmployeeMapper, Employee> implements EmployeeService {
    
    @Autowired
    private EmployeeMapper employeeMapper;
    
    @Autowired
    private LoginLogService loginLogService;  // 登录日志服务
    
    @Override
    public R<Employee> login(HttpServletRequest request, Employee employee) {
        String username = employee.getUsername();
        String password = employee.getPassword();
        
        log.info("用户尝试登录: {}", username);
        
        // 1. 参数校验
        if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) {
            log.warn("登录参数为空: username={}", username);
            return R.error("用户名或密码不能为空");
        }
        
        // 2. 构建查询条件(防SQL注入)
        LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(Employee::getUsername, username);
        
        Employee emp = null;
        try {
            // 3. 查询用户(这里用MyBatis-Plus的getOne,确保只查一条)
            emp = this.getOne(queryWrapper);
        } catch (Exception e) {
            log.error("查询用户失败: {}, error: {}", username, e.getMessage());
            return R.error("系统异常,请稍后重试");
        }
        
        // 4. 用户不存在
        if (emp == null) {
            log.warn("用户不存在: {}", username);
            
            // 记录登录失败日志(即使失败也要记录)
            loginLogService.saveLoginLog(username, "用户不存在", false);
            
            return R.error("用户名或密码错误");
        }
        
        // 5. 密码加密比较
        String md5Password = DigestUtils.md5DigestAsHex(password.getBytes());
        if (!emp.getPassword().equals(md5Password)) {
            log.warn("密码错误: {}", username);
            
            loginLogService.saveLoginLog(username, "密码错误", false);
            
            return R.error("用户名或密码错误");
        }
        
        // 6. 检查账户状态
        if (emp.getStatus() == 0) {
            log.warn("账户被禁用: {}", username);
            
            loginLogService.saveLoginLog(username, "账户被禁用", false);
            
            return R.error("账户已被禁用");
        }
        
        // 7. 登录成功,记录日志
        log.info("用户登录成功: {}", username);
        loginLogService.saveLoginLog(username, "登录成功", true);
        
        // 8. 保存登录状态到Session
        request.getSession().setAttribute("employee", emp.getId());
        
        // 9. 返回用户信息(脱敏处理)
        EmployeeVO employeeVO = new EmployeeVO();
        BeanUtils.copyProperties(emp, employeeVO);
        // 脱敏处理
        employeeVO.setPassword(null);
        employeeVO.setIdNumber(maskIdNumber(emp.getIdNumber()));
        
        return R.success(employeeVO);
    }
    
    private String maskIdNumber(String idNumber) {
        if (StringUtils.isEmpty(idNumber) || idNumber.length() < 8) {
            return idNumber;
        }
        // 身份证号脱敏:保留前4位和后4位
        return idNumber.substring(0, 4) + "********" + idNumber.substring(idNumber.length() - 4);
    }
}

四、全局异常处理:从"红屏报错"到"优雅提示"

4.1 那个让用户恐慌的500错误页面

用户反馈:"我点了提交,页面变成了白底红字,全是英文,吓死我了!"

我的代码没有任何异常处理,Spring Boot的默认错误页面直接暴露给了用户。

4.2 全局异常处理器

java

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    
    /**
     * 处理业务异常
     */
    @ExceptionHandler(BusinessException.class)
    public R<String> handleBusinessException(BusinessException e) {
        log.error("业务异常: {}", e.getMessage(), e);
        return R.error(e.getMessage());
    }
    
    /**
     * 处理SQL异常
     */
    @ExceptionHandler(SQLException.class)
    public R<String> handleSQLException(SQLException e) {
        log.error("SQL异常: {}", e.getMessage(), e);
        
        // 判断是否为唯一约束冲突
        if (e.getMessage().contains("Duplicate entry")) {
            String[] split = e.getMessage().split(" ");
            String value = split[2].replace("'", "");
            return R.error(value + "已存在");
        }
        
        return R.error("数据库操作失败");
    }
    
    /**
     * 处理参数验证异常
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public R<String> handleValidationException(MethodArgumentNotValidException e) {
        log.error("参数验证异常: {}", e.getMessage());
        
        // 提取所有错误信息
        List<String> errors = e.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(DefaultMessageSourceResolvable::getDefaultMessage)
                .collect(Collectors.toList());
        
        return R.error(String.join(", ", errors));
    }
    
    /**
     * 处理系统异常
     */
    @ExceptionHandler(Exception.class)
    public R<String> handleException(Exception e) {
        log.error("系统异常: ", e);
        
        // 生产环境返回友好提示,不暴露具体错误
        if (isProduction()) {
            return R.error("系统异常,请稍后重试");
        }
        
        // 开发环境返回详细错误
        return R.error("系统异常: " + e.getMessage());
    }
    
    private boolean isProduction() {
        String env = System.getProperty("spring.profiles.active");
        return "prod".equals(env);
    }
}

4.3 自定义业务异常

java

public class BusinessException extends RuntimeException {
    
    private Integer code;
    
    public BusinessException(String message) {
        super(message);
        this.code = 500; // 默认错误码
    }
    
    public BusinessException(Integer code, String message) {
        super(message);
        this.code = code;
    }
    
    public BusinessException(String message, Throwable cause) {
        super(message, cause);
        this.code = 500;
    }
    
    public Integer getCode() {
        return code;
    }
}

五、统一返回格式:从"随心所欲"到"标准统一"

5.1 我之前的混乱返回

java

// Controller 1
@GetMapping("/list")
public List<Employee> list() {
    return employeeService.list();
}

// Controller 2  
@PostMapping("/add")
public String add(@RequestBody Employee employee) {
    employeeService.save(employee);
    return "success";
}

// Controller 3
@PutMapping("/update")
public boolean update(@RequestBody Employee employee) {
    return employeeService.updateById(employee);
}

问题:前端需要为每个接口写不同的处理逻辑。

5.2 统一返回格式

java

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class R<T> implements Serializable {
    
    private static final long serialVersionUID = 1L;
    
    // 状态码
    private Integer code;
    
    // 消息
    private String msg;
    
    // 数据
    private T data;
    
    // 时间戳
    private Long timestamp;
    
    // 成功响应
    public static <T> R<T> success() {
        return success(null);
    }
    
    public static <T> R<T> success(T data) {
        return R.<T>builder()
                .code(200)
                .msg("success")
                .data(data)
                .timestamp(System.currentTimeMillis())
                .build();
    }
    
    public static <T> R<T> success(String msg, T data) {
        return R.<T>builder()
                .code(200)
                .msg(msg)
                .data(data)
                .timestamp(System.currentTimeMillis())
                .build();
    }
    
    // 错误响应
    public static <T> R<T> error(String msg) {
        return error(500, msg);
    }
    
    public static <T> R<T> error(Integer code, String msg) {
        return R.<T>builder()
                .code(code)
                .msg(msg)
                .data(null)
                .timestamp(System.currentTimeMillis())
                .build();
    }
    
    // 快捷方法
    public static <T> R<T> ok() {
        return success();
    }
    
    public static <T> R<T> ok(T data) {
        return success(data);
    }
    
    public static <T> R<T> ok(String msg, T data) {
        return success(msg, data);
    }
    
    // 判断是否成功
    public boolean isSuccess() {
        return code != null && code == 200;
    }
}

使用方式

java

@GetMapping("/{id}")
public R<EmployeeVO> getById(@PathVariable Long id) {
    Employee employee = employeeService.getById(id);
    if (employee == null) {
        return R.error(404, "员工不存在");
    }
    
    EmployeeVO vo = convertToVO(employee);
    return R.success(vo);
}

@PostMapping
public R<String> add(@RequestBody @Valid EmployeeDTO dto) {
    employeeService.add(dto);
    return R.success("添加成功");
}

六、分页查询:从"全量加载"到"性能优化"

6.1 我写的"性能杀手"

java

@GetMapping("/list")
public List<Employee> list() {
    // 查所有数据!如果表里有100万条记录...
    return employeeService.list();
}

6.2 使用MyBatis-Plus分页

java

@Configuration
public class MybatisPlusConfig {
    
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        
        // 分页插件
        PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor();
        paginationInterceptor.setDbType(DbType.MYSQL);
        paginationInterceptor.setMaxLimit(1000L); // 单页最大1000条
        paginationInterceptor.setOverflow(true);  // 超过总页数后返回第一页
        
        interceptor.addInnerInterceptor(paginationInterceptor);
        
        // 乐观锁插件(可选)
        OptimisticLockerInnerInterceptor optimisticLockerInnerInterceptor = new OptimisticLockerInnerInterceptor();
        interceptor.addInnerInterceptor(optimisticLockerInnerInterceptor);
        
        return interceptor;
    }
}

java

@Service
public class EmployeeServiceImpl extends ServiceImpl<EmployeeMapper, Employee> 
    implements EmployeeService {
    
    @Override
    public R<PageResult<EmployeeVO>> pageQuery(EmployeeQueryDTO queryDTO) {
        // 1. 构建分页条件
        Page<Employee> page = new Page<>(queryDTO.getPage(), queryDTO.getSize());
        
        // 2. 构建查询条件
        LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
        
        // 姓名模糊查询
        if (StringUtils.isNotBlank(queryDTO.getName())) {
            queryWrapper.like(Employee::getName, queryDTO.getName());
        }
        
        // 用户名精确查询
        if (StringUtils.isNotBlank(queryDTO.getUsername())) {
            queryWrapper.eq(Employee::getUsername, queryDTO.getUsername());
        }
        
        // 状态查询
        if (queryDTO.getStatus() != null) {
            queryWrapper.eq(Employee::getStatus, queryDTO.getStatus());
        }
        
        // 创建时间范围查询
        if (queryDTO.getBeginTime() != null && queryDTO.getEndTime() != null) {
            queryWrapper.between(Employee::getCreateTime, 
                                queryDTO.getBeginTime(), 
                                queryDTO.getEndTime());
        }
        
        // 排序
        queryWrapper.orderByDesc(Employee::getUpdateTime);
        
        // 3. 执行分页查询
        Page<Employee> employeePage = this.page(page, queryWrapper);
        
        // 4. 转换为VO
        List<EmployeeVO> voList = employeePage.getRecords().stream()
                .map(this::convertToVO)
                .collect(Collectors.toList());
        
        // 5. 构建分页结果
        PageResult<EmployeeVO> pageResult = PageResult.<EmployeeVO>builder()
                .list(voList)
                .total(employeePage.getTotal())
                .page(queryDTO.getPage())
                .size(queryDTO.getSize())
                .pages(employeePage.getPages())
                .build();
        
        return R.success(pageResult);
    }
    
    private EmployeeVO convertToVO(Employee employee) {
        EmployeeVO vo = new EmployeeVO();
        BeanUtils.copyProperties(employee, vo);
        
        // 脱敏处理
        vo.setPassword("******");
        vo.setIdNumber(maskIdNumber(employee.getIdNumber()));
        vo.setPhone(maskPhone(employee.getPhone()));
        
        return vo;
    }
    
    private String maskPhone(String phone) {
        if (StringUtils.isEmpty(phone) || phone.length() < 7) {
            return phone;
        }
        return phone.substring(0, 3) + "****" + phone.substring(7);
    }
}

七、项目部署:从"本地王者"到"生产乞丐"

7.1 我的第一次部署尝试

  1. 把jar包用QQ发给运维
  2. "你帮我放到服务器上,运行一下"
  3. 然后就没有然后了...

7.2 完整的部署流程

image-20260202164405771

7.2.1 服务器准备

bash

#!/bin/bash
# server-init.sh

echo "=== 开始初始化服务器 ==="

# 1. 安装JDK
echo "1. 安装JDK 11..."
yum install -y java-11-openjdk-devel

# 2. 安装MySQL
echo "2. 安装MySQL 8.0..."
wget https://dev.mysql.com/get/mysql80-community-release-el7-7.noarch.rpm
rpm -ivh mysql80-community-release-el7-7.noarch.rpm
yum install -y mysql-community-server
systemctl start mysqld
systemctl enable mysqld

# 3. 初始化数据库
echo "3. 初始化数据库..."
mysql -e "CREATE DATABASE IF NOT EXISTS reggie DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;"

# 4. 安装Nginx
echo "4. 安装Nginx..."
yum install -y nginx
systemctl start nginx
systemctl enable nginx

echo "=== 服务器初始化完成 ==="

7.2.2 应用部署脚本

bash

#!/bin/bash
# deploy.sh

set -e

APP_NAME="reggie"
APP_PORT=8080
BACKUP_DIR="/opt/backup/$(date +%Y%m%d_%H%M%S)"
DEPLOY_DIR="/opt/$APP_NAME"

echo "=== 开始部署 $APP_NAME ==="

# 1. 停止旧服务
echo "1. 停止旧服务..."
systemctl stop $APP_NAME.service 2>/dev/null || true

# 2. 备份旧版本
echo "2. 备份旧版本..."
mkdir -p $BACKUP_DIR
cp -r $DEPLOY_DIR/* $BACKUP_DIR/ 2>/dev/null || true

# 3. 清理部署目录
echo "3. 清理部署目录..."
rm -rf $DEPLOY_DIR/*
mkdir -p $DEPLOY_DIR/{app,logs,config}

# 4. 复制新版本
echo "4. 复制新版本..."
cp target/*.jar $DEPLOY_DIR/app/$APP_NAME.jar
cp -r src/main/resources/static $DEPLOY_DIR/app/
cp deploy/application-prod.yml $DEPLOY_DIR/config/application.yml

# 5. 创建服务文件
echo "5. 创建服务文件..."
cat > /etc/systemd/system/$APP_NAME.service << EOF
[Unit]
Description=$APP_NAME Service
After=network.target

[Service]
Type=simple
User=nobody
WorkingDirectory=$DEPLOY_DIR/app
ExecStart=/usr/bin/java -jar $APP_NAME.jar --spring.config.location=../config/application.yml
Restart=always
RestartSec=10
StandardOutput=append:$DEPLOY_DIR/logs/app.log
StandardError=append:$DEPLOY_DIR/logs/error.log

[Install]
WantedBy=multi-user.target
EOF

# 6. 设置权限
echo "6. 设置权限..."
chown -R nobody:nobody $DEPLOY_DIR
chmod 755 $DEPLOY_DIR/app/$APP_NAME.jar

# 7. 启动服务
echo "7. 启动服务..."
systemctl daemon-reload
systemctl start $APP_NAME.service
systemctl enable $APP_NAME.service

# 8. 检查服务状态
echo "8. 检查服务状态..."
sleep 5
if systemctl is-active --quiet $APP_NAME.service; then
    echo "✅ 服务启动成功"
    
    # 检查端口是否监听
    if netstat -tlnp | grep ":$APP_PORT" > /dev/null; then
        echo "✅ 端口 $APP_PORT 监听正常"
    else
        echo "❌ 端口 $APP_PORT 未监听"
        exit 1
    fi
else
    echo "❌ 服务启动失败"
    echo "查看日志: tail -f $DEPLOY_DIR/logs/error.log"
    exit 1
fi

echo "=== 部署完成 ==="

7.2.3 Nginx配置

nginx

# /etc/nginx/conf.d/reggie.conf
upstream reggie_backend {
    server 127.0.0.1:8080;
    # 如果有多个实例可以做负载均衡
    # server 127.0.0.1:8081;
}

server {
    listen 80;
    server_name your-domain.com;
    
    # 前端页面
    location / {
        root /opt/reggie/app/static;
        index index.html;
        
        # 解决Vue/React路由刷新404
        try_files $uri $uri/ /index.html;
        
        # 缓存静态资源
        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
            expires 1y;
            add_header Cache-Control "public, immutable";
        }
    }
    
    # 后端API
    location /api/ {
        proxy_pass http://reggie_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        
        # 超时设置
        proxy_connect_timeout 30s;
        proxy_send_timeout 30s;
        proxy_read_timeout 30s;
        
        # 文件上传大小限制
        client_max_body_size 10m;
    }
}

八、监控和日志:从"瞎子摸象"到"明察秋毫"

8.1 健康检查接口

java

@RestController
@RequestMapping("/actuator")
@Slf4j
public class HealthController {
    
    @Autowired
    private DataSource dataSource;
    
    @GetMapping("/health")
    public Map<String, Object> health() {
        Map<String, Object> result = new HashMap<>();
        
        // 应用状态
        result.put("status", "UP");
        result.put("timestamp", System.currentTimeMillis());
        
        // 数据库状态
        Map<String, Object> dbStatus = new HashMap<>();
        try {
            Connection connection = dataSource.getConnection();
            boolean valid = connection.isValid(2); // 2秒超时
            connection.close();
            
            dbStatus.put("status", valid ? "UP" : "DOWN");
        } catch (Exception e) {
            dbStatus.put("status", "DOWN");
            dbStatus.put("error", e.getMessage());
            log.error("数据库健康检查失败", e);
        }
        result.put("database", dbStatus);
        
        // 系统信息
        Runtime runtime = Runtime.getRuntime();
        Map<String, Object> systemInfo = new HashMap<>();
        systemInfo.put("memory_used", (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024);
        systemInfo.put("memory_total", runtime.totalMemory() / 1024 / 1024);
        systemInfo.put("memory_max", runtime.maxMemory() / 1024 / 1024);
        systemInfo.put("cpu_cores", runtime.availableProcessors());
        systemInfo.put("uptime", ManagementFactory.getRuntimeMXBean().getUptime());
        result.put("system", systemInfo);
        
        return result;
    }
}

8.2 日志配置

yaml

# application-prod.yml
logging:
  level:
    root: INFO
    com.reggie: DEBUG
    org.springframework.web: WARN
    org.hibernate: WARN
  
  # 文件输出
  file:
    name: logs/application.log
  
  # 日志格式
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
    file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
  
  # 日志切割
  logback:
    rollingpolicy:
      max-file-size: 10MB
      max-history: 30
      total-size-cap: 1GB

九、经验总结:从"会写代码"到"会做项目"

9.1 我踩过的坑

  1. 环境不一致:本地能跑,服务器不能跑
  2. 权限问题:文件权限、数据库权限、服务权限
  3. 资源不足:内存不足、磁盘不足、连接数不足
  4. 配置错误:端口冲突、路径错误、依赖缺失
  5. 监控缺失:不知道系统状态,出问题只能猜

9.2 我的最佳实践

  1. 统一配置:使用配置中心管理所有配置
  2. 容器化部署:使用Docker保证环境一致
  3. 自动化部署:CI/CD流水线
  4. 完善监控:应用监控、业务监控、日志监控
  5. 健全告警:异常告警、性能告警、安全告警

9.3 如果重来一次

我会:

  1. 从一开始就考虑部署和运维
  2. 建立完善的监控体系
  3. 写详细的文档和脚本
  4. 设计可扩展的架构
  5. 重视安全和性能

结语:从程序员到工程师

从那个对着空白屏幕绝望的新手,到现在能从容处理线上故障的工程师,我最大的感悟是:

技术只是工具,解决问题才是目的。

一个好的工程师,不仅要会写代码,还要:

  • 理解业务需求
  • 设计合理架构
  • 考虑运维成本
  • 重视用户体验
  • 持续学习改进

记住:代码的质量,决定了你加班的时间。架构的合理性,决定了项目的寿命。

知识点测试

读完文章了?来测试一下你对知识点的掌握程度吧!

评论区

使用 GitHub 账号登录后即可发表评论,支持 Markdown 格式。

如果评论系统无法加载,请确保:

  • 您的网络可以访问 GitHub
  • giscus GitHub App 已安装到仓库
  • 仓库已启用 Discussions 功能